초심자 입장에서 Flutter Riverpod을 '잘' 사용하는 방법 (장문)

September 20, 2025

LG전자 산학 캡스톤에서 Flutter로 프로젝트를 진행하기 전 프리코스로 TODO 리스트 개발 과제가 주어졌다.

📋 요구사항

- 투두리스트 기본적인 기능 구현
- Riverpod 상태 관리 구현
- DevTools 사용

그리고 따로 README 파일을 만들어서 상태 자료구조, widget 설명, DevTools에서 Inspector&Timeline&Memory&Performance 화면을 스크린샷해야 한다.

TODO 리스트 그냥 띡 만들어봐가 아닌 거 같아서, 나도 이에 부응해서 Flutter, Riverpod 공식문서를 살펴보면서 관련 내용을 학습하며 투두 리스트를 완성했다. 오늘은 이 과정 속 내가 학습한 내용에 대해 다뤄보고자 한다.

1️⃣ Riverpod 핵심 개념 완전 정복

1.1 Riverpod이 뭘까? 🎯

Riverpod은 Flutter에서 사용하는 상태 관리 라이브러리다. 쉽게 말해서 앱의 데이터를 여러 화면에서 공유하고 관리할 수 있게 해주는 도구라고 생각하면 된다.

왜 상태 관리가 필요할까?🤔

Flutter에서 기본적으로 제공하는 setState()는 한 화면 안에서만 데이터를 관리할 수 있다. 하지만 실제 앱을 만들다 보면:

  • 로그인한 사용자 정보를 여러 화면에서 사용해야 한다
  • 장바구니 데이터를 상품 목록과 결제 화면에서 공유해야 한다
  • 다크 모드 설정을 전체 앱에 적용해야 한다

이럴 때 Riverpod이 빛을 발한다!

Riverpod의 장점
  • 🔒 타입 안전성: 컴파일할 때 오류를 미리 잡아준다
  • 🧪 테스트하기 쉬움: 가짜 데이터로 테스트하기 편하다
  • 📱 DevTools 지원: 상태 변화를 눈으로 확인할 수 있다
  • 🏗️ 확장성: 큰 프로젝트에서도 깔끔하게 관리된다

1.2 Riverpod 아키텍처 한눈에 보기 📐

Riverpod은 4가지 핵심 요소로 구성되어 있다:

🏪 Provider (상점)         ↔️  📱 Consumer (고객)
      ⬇️                       ⬆️
🎪 ProviderScope          ↔️  🔗 Ref (다리)
   (쇼핑몰 관리소)              (연결고리)

간단한 비유로 이해하기:

  • 🏪 Provider: 데이터를 파는 상점
  • 📱 Consumer: 데이터를 사는 고객 (UI)
  • 🎪 ProviderScope: 모든 상점을 관리하는 쇼핑몰 (Flutter Widget용)
  • 🔗 Ref: 고객과 상점을 연결하는 다리 역할

💡 참고: 실제로는 ProviderContainer가 핵심 관리 시스템이지만, Flutter 앱에서는 ProviderScope(Widget 버전)를 사용하는 것이 권장된다. Todo 앱에서도 ProviderScope를 사용했다!

이제 각각을 자세히 알아보자!

1.3 🏪 Provider 핵심 개념

Provider는 데이터를 제공하는 상점이다. 앱에서 필요한 데이터를 생성하고 관리하는 역할을 한다.

Provider 타입별 특징:

// 🎯 StateProvider - 간단한 값 하나를 관리
final counterProvider = StateProvider<int>((ref) => 0);

// 🧠 NotifierProvider - 복잡한 로직이 있는 상태 관리
final todoListProvider = NotifierProvider<TodoNotifier, List<Todo>>(
  TodoNotifier.new
);

// ⏰ FutureProvider - 비동기 데이터 관리
final weatherProvider = FutureProvider<Weather>((ref) async {
  return await api.getWeather();
});

// 🔄 StreamProvider - 실시간 데이터 스트림
final chatProvider = StreamProvider<List<Message>>((ref) {
  return chatService.messagesStream;
});

Provider 타입 선택 가이드:

Provider 타입 사용 시기 예시
StateProvider 간단한 값 하나 카운터, 다크모드 설정
NotifierProvider 복잡한 비즈니스 로직 Todo 리스트, 장바구니
FutureProvider API 호출 (비동기) 사용자 프로필 조회
StreamProvider 실시간 데이터 채팅, 알림

1.4 🎪 ProviderScope 핵심 개념

ProviderScope는 Riverpod의 Flutter 전용 진입점이다. 모든 Provider들이 작동할 수 있는 환경을 제공하는 특별한 Widget이다.

Todo 앱에서 ProviderScope를 선택한 이유:

// main.dart - 앱의 시작점
void main() {
  runApp(
    ProviderScope(  // 👈 Flutter 앱에서는 이것을 사용!
      child: TodoApp(),
    ),
  );
}

ProviderScope vs ProviderContainer:

  • ProviderContainer: Riverpod의 핵심 엔진 (모든 상태 관리의 중심)
  • ProviderScope: ProviderContainer의 Widget 래퍼 (Flutter 앱에서 사용하기 위한 형태)

Todo 앱에서는 Flutter Widget 트리와 자연스럽게 통합되어야 하므로 ProviderScope를 사용했다!

ProviderScope의 역할
  1. 🏠 Provider 환경 제공: Provider들이 작동할 수 있는 컨텍스트 생성
  2. 🔧 테스트 지원: 특정 Provider를 가짜 구현으로 교체 가능
  3. 💾 자동 메모리 관리: 사용하지 않는 Provider 자동 정리
// 테스트에서 활용하는 예시
testWidgets('Todo 추가 테스트', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        // 실제 Provider를 테스트용으로 교체
        todoListProvider.overrideWith(() => MockTodoNotifier()),
      ],
      child: TodoApp(),
    ),
  );
});

참고로 ProviderContainer를 통해서도 Provider를 통한 상태 관리를 할 수 있다.

💡 이번 TODO 앱에서는 ProviderScope 를 사용했다. 공식 문서에 따르면 Flutter 애플리케이션에서 ProviderContianer를 직접적으로 사용하는 건 지양해야 한다고 한다. 대신 다음과 같은 특수한 상황에서 유용하다고 한다:

  • 테스트 환경: 위젯 없이 provider 로직만 테스트할 때
  • 백그라운드 서비스: UI와 분리된 백그라운드 작업에서 상태 관리가 필요할 때
  • 서버 사이드: Dart 서버 애플리케이션에서 Riverpod을 사용할 때

1.5 🔗 Ref 핵심 개념

Ref는 Provider와 Consumer를 연결하는 다리 역할을 한다. Widget에서 Provider의 데이터에 접근하거나 상태를 변경할 때 사용한다.

Ref의 주요 메서드들
class TodoPage extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    // 📡 ref.watch() - 상태 구독 (변화 감지)
    final todos = ref.watch(todoListProvider);

    // 🎯 ref.read() - 일회성 접근 (변화 감지 안함)
    final notifier = ref.read(todoListProvider.notifier);

    // 👂 ref.listen() - 상태 변화 감지해서 부수 효과 실행
    ref.listen(todoListProvider, (previous, next) {
      if (next.length > previous?.length) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('새 할 일이 추가되었어요!'))
        );
      }
    });

    return Column(
      children: [
        Text('할 일 ${todos.length}개'),
        ElevatedButton(
          onPressed: () => notifier.add('새 할 일'),
          child: Text('추가'),
        ),
      ],
    );
  }
}
Ref의 메서드 종류
메서드 용도 리빌드 여부 사용 예시
ref.watch() 데이터 표시 ✅ 자동 리빌드 UI에 상태 표시
ref.read() 데이터 변경 ❌ 리빌드 안함 버튼 클릭시 상태 변경
ref.listen() 부수 효과 ❌ 리빌드 안함 알림, 네비게이션

1.6 📱 Consumer Widget 핵심 개념

Consumer Widget은 Provider의 데이터를 구독하고 UI에 표시하는 특별한 위젯이다. 일반 Widget과 달리 WidgetRef ref 매개변수를 통해 Provider에 접근할 수 있다.

// ❌ 일반 Widget - Provider 접근 불가
class NormalWidget extends StatelessWidget {
  
  Widget build(BuildContext context) {
    // final todos = ref.watch(todoListProvider);  // 💥 에러!
    return Text('Provider 접근 불가');
  }
}

// ✅ Consumer Widget - Provider 접근 가능!
class TodoCounter extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final todos = ref.watch(todoListProvider);  // ✨ 가능!
    return Text('할 일 ${todos.length}개');
  }
}
Todo 앱에서 Consumer Widget 활용 사례

Todo 앱에서는 주로 ConsumerWidget을 사용한다:

// 1. TodoList - Todo 목록을 표시하는 Widget
class TodoList extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final todos = ref.watch(todoListProvider);  // 👈 Todo 리스트 구독

    return ListView.builder(
      itemCount: todos.length,
      itemBuilder: (context, index) => TodoItem(todo: todos[index]),
    );
  }
}

// 2. TodoItem - 개별 Todo 항목을 표시하는 Widget
class TodoItem extends ConsumerWidget {
  final Todo todo;

  const TodoItem({required this.todo});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return ListTile(
      leading: Checkbox(
        value: todo.isDone,
        onChanged: (_) => ref.read(todoListProvider.notifier).toggle(todo.id),
      ),
      title: Text(todo.title),
      trailing: IconButton(
        icon: Icon(Icons.delete),
        onPressed: () => ref.read(todoListProvider.notifier).remove(todo.id),
      ),
    );
  }
}
Consumer Widget의 역할
  • 📡 ref.watch()로 상태를 구독하면 상태 변경시 자동으로 리빌드
  • 🎯 ref.read()로 상태를 변경할 때는 리빌드되지 않음
  • 🔄 Todo가 추가/삭제/수정되면 TodoList와 TodoItem이 자동으로 업데이트됨

1.7 🔄 setState vs Riverpod 비교

기존 setState 방식의 한계

class _CounterPageState extends State<CounterPage> {
  int count = 0;  // 🚫 이 화면에서만 사용 가능

  void increment() {
    setState(() {
      count++;  // 🚫 이 Widget만 업데이트
    });
  }
}

👿문제점

  • 다른 화면에서 count 값을 사용할 수 없음
  • 데이터가 복잡해지면 관리가 어려움
  • 부모에서 자식으로 데이터를 전달하려면 여러 단계를 거쳐야 함
Riverpod 방식
// 1️⃣ Provider 정의 (전역에서 접근 가능!)
final counterProvider = StateProvider<int>((ref) => 0);

// 2️⃣ Consumer Widget을 통한 값 구독
class CounterDisplay extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);  // 값 구독
    return Text('$count');
  }
}

// 3️⃣ 어디서든 값 변경 가능
class CounterButton extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    return ElevatedButton(
      onPressed: () => ref.read(counterProvider.notifier).state++,
      child: Text('증가'),
    );
  }
}

아래와 같은 장점이 있다.

  • 전역 접근: 어느 화면에서든 상태 사용 가능
  • 자동 업데이트: 상태 변경시 모든 구독자가 자동 업데이트
  • 타입 안전성: 컴파일 타임에 오류 검출
  • 테스트 용이성: Provider 교체로 쉬운 테스트

1.8 🎯 설치 및 설정 방법

패키지 설치 (권장 방법)

# 메인 라이브러리 설치
flutter pub add flutter_riverpod

# 개발용 도구 설치 (코드 생성용)
flutter pub add --dev riverpod_generator build_runner
// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(
    ProviderScope(  // 👈 필수! 앱 전체를 감싸기
      child: MyApp(),
    ),
  );
}

이제 Riverpod의 4가지 핵심 개념을 모두 알아봤다! 다음 섹션에서는 실제 Todo 앱을 만들면서 이 개념들을 적용해보자.

2️⃣ Todo 앱 만들면서 Riverpod 적용하기

이제 실제로 Todo 앱을 만들면서 앞서 학습한 Riverpod의 4가지 핵심 개념을 모두 적용해보자!

우리가 만들 Todo 앱의 기능:

- Todo 추가하기
- Todo 완료/미완료 토글하기
- Todo 삭제하기
- 완료된 Todo 개수 표시하기

2.1 프로젝트 기본 설정

먼저 main.dart에서 ProviderScope로 앱을 감싸자:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';

void main() {
  runApp(
    const ProviderScope(  // 👈 핵심! Todo 앱의 모든 상태 관리 시작점
      child: App(),       //    이 안에서만 Provider 사용 가능하다
    ),
  );
}

💡 중요! ProviderScope 없이는 ref.watch(), ref.read() 등을 사용할 수 없다. Todo 앱의 모든 기능이 이 ProviderScope 덕분에 작동한다!

2.2 모델 정의

Todo가 어떤 정보를 가질지 정의해보자. class를 선언하자. 보다시피 Flutter가 기반으로 하는 Dart는 객체 지향 언이이다.

// models/todo.dart
class Todo {
  final String id;        // 고유 식별자
  final String title;     // 할 일 내용
  final bool isDone;      // 완료 여부

  const Todo({
    required this.id,
    required this.title,
    this.isDone = false  // 기본값은 미완료
  });

  // 불변성을 유지하면서 일부 값만 변경하는 메서드
  Todo copyWith({String? id, String? title, bool? isDone}) {
    return Todo(
      id: id ?? this.id,           // 새 값이 없으면 기존 값 사용
      title: title ?? this.title,
      isDone: isDone ?? this.isDone,
    );
  }
}
copyWith를 사용할까?

Riverpod에서는 React와 마찬가지로 상태를 업데이트할 때 불변성(Immutability) 이 중요하다. 기존 객체를 직접 수정하지 않고, 새로운 객체를 만들어서 상태를 변경한다. 이렇게 해야 Riverpod이 변화를 감지하고 UI를 업데이트할 수 있다!

이건 React 해봤으면 익숙할듯

3. Notifier로 상태관리하기

3.1 Notifier 핵심 개념

Notifier는 Riverpod에서 복잡한 상태 로직을 관리하는 클래스다. 단순한 값 하나만 관리하는 StateProvider와 달리, Notifier는:

  • 비즈니스 로직을 캡슐화할 수 있다
  • 여러 개의 메서드로 상태를 조작할 수 있다
  • 상태 변화의 규칙을 명확하게 정의할 수 있다

쉽게 말해서, Notifier는 상태의 관리자(Controller) 역할을 한다.

// 예시: 간단한 카운터 Notifier
class CounterNotifier extends Notifier<int> {
  
  int build() => 0;  // 초기값

  void increment() => state++;     // 증가
  void decrement() => state--;     // 감소
  void reset() => state = 0;       // 리셋
}

3.2 상태 업데이트 메커니즘

Notifier에서 상태를 업데이트하는 과정은 다음과 같다:

  1. 메서드 호출: UI에서 notifier.add()같은 메서드를 호출
  2. 상태 계산: Notifier 내부에서 새로운 상태를 계산
  3. 상태 할당: state = newValue로 상태를 업데이트
  4. UI 자동 업데이트: 이 Provider를 구독하는 모든 Widget이 자동으로 리빌드
// 상태 업데이트 흐름 예시
class TodoNotifier extends Notifier<List<Todo>> {
  void add(String title) {
    // 1. 새로운 Todo 객체 생성
    final newTodo = Todo(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      title: title,
    );

    // 2. 기존 상태에 새 Todo 추가한 새 리스트 생성
    final newState = [...state, newTodo];

    // 3. 상태 업데이트 (이 순간 UI가 자동으로 업데이트됨!)
    state = newState;
  }
}

3.3 TodoNotifier 구현

이제 Todo 리스트를 관리하는 완전한 비즈니스 로직을 만들어보자:

// providers/todo_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/todo.dart';

class TodoNotifier extends Notifier<List<Todo>> {
  
  List<Todo> build() {
    // 초기 상태 - 빈 리스트로 시작
    return [];
  }

  // ✅ Todo 추가 로직
  void add(String title) {
    // 유효성 검사
    if (title.trim().isEmpty) return;

    final id = DateTime.now().millisecondsSinceEpoch.toString();
    final newTodo = Todo(id: id, title: title.trim());

    // 불변성을 유지하며 새 상태 생성
    state = [...state, newTodo];
    // 👆 이 순간 TodoList Widget이 자동으로 리빌드됨!
  }

  // ✅ Todo 완료/미완료 토글 로직
  void toggle(String id) {
    state = [
      for (final todo in state)
        if (todo.id == id)
          todo.copyWith(isDone: !todo.isDone)  // 특정 Todo만 상태 변경
        else
          todo,  // 나머지는 그대로 유지
    ];
    // 👆 체크박스가 자동으로 업데이트됨!
  }

  // ✅ Todo 삭제 로직
  void remove(String id) {
    state = state.where((todo) => todo.id != id).toList();
    // 👆 해당 TodoItem이 화면에서 자동으로 사라짐!
  }

  // 🔍 추가 기능: 완료된 Todo 개수 계산
  int get completedCount => state.where((todo) => todo.isDone).length;

  // 🔍 추가 기능: 전체 완료 토글
  void toggleAll() {
    final allCompleted = state.every((todo) => todo.isDone);
    state = [
      for (final todo in state)
        todo.copyWith(isDone: !allCompleted)
    ];
  }
}

핵심 포인트:

  • ⭐️ build(): 초기 상태를 정의 (한 번만 실행됨)
  • state = newValue: 상태 업데이트, 이 순간 UI가 리빌드됨
  • 불변성: 기존 객체를 수정하지 않고 새 객체/리스트를 생성
  • 유효성 검사: 비즈니스 로직을 Notifier에 캡슐화

3.4 UI 반영 과정

// 1. 사용자가 버튼 클릭
ElevatedButton(
  onPressed: () {
    // 2. Notifier 메서드 호출
    ref.read(todoListProvider.notifier).add('새 할 일');
  },
  child: Text('추가'),
)

// 3. TodoNotifier.add() 실행
void add(String title) {
  state = [...state, Todo(id: '1', title: title)];  // 상태 변경!
}

// 4. TodoList Widget이 자동으로 리빌드
class TodoList extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final todos = ref.watch(todoListProvider);  // 👈 새 상태를 자동으로 받음!

    return ListView.builder(
      itemCount: todos.length,  // 👈 개수가 자동으로 업데이트됨!
      itemBuilder: (_, i) => TodoItem(todo: todos[i]),
    );
  }
}

3.5 Provider 연결

이제 UI에서 사용할 수 있도록 Provider를 만든다:

// providers/todo_providers.dart 파일에 추가
final todoListProvider = NotifierProvider<TodoNotifier, List<Todo>>(
  TodoNotifier.new,  // TodoNotifier 생성자를 간단히 전달
);

이제 todoListProvider를 통해 어디서든 Todo 리스트에 접근할 수 있다!

Provider 연결의 핵심:

  • NotifierProvider<Notifier클래스, 상태타입>: Notifier를 Provider로 등록
  • TodoNotifier.new: 클래스의 생성자를 전달 이렇게 하면 어느 Widget에서든 이 Provider를 사용할 수 있다.

3.6 ref.watch vs ref.read 활용

이 둘의 차이점을 명확히 알아야 한다!

ref.watch - 상태 구독 👀

final todos = ref.watch(todoListProvider);
// todos가 변하면 이 Widget이 다시 그려진다!

ref.read - 일회성 접근 🎯

final notifier = ref.read(todoListProvider.notifier);
notifier.add('새 할 일');
// 값만 읽거나 메서드를 호출할 때 사용, Widget은 다시 그려지지 않음

언제 뭘 사용할까?

  • ref.watch: UI에 데이터를 표시할 때
  • ref.read: 버튼을 눌렀을 때 상태를 변경할 때

실제 사용 예시:

class TodoPage extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔍 상태 읽기 - UI가 상태 변화를 구독
    final todos = ref.watch(todoListProvider);
    final completedCount = ref.read(todoListProvider.notifier).completedCount;

    return Column(
      children: [
        Text('총 ${todos.length}개, 완료 ${completedCount}개'),

        ElevatedButton(
          onPressed: () {
            // ✏️ 상태 변경 - 메서드 호출
            ref.read(todoListProvider.notifier).add('새 할 일');
          },
          child: Text('Todo 추가'),
        ),

        // 📋 Todo 리스트 표시
        Expanded(
          child: ListView.builder(
            itemCount: todos.length,
            itemBuilder: (context, index) {
              final todo = todos[index];
              return ListTile(
                title: Text(todo.title),
                leading: Checkbox(
                  value: todo.isDone,
                  onChanged: (_) {
                    // ✏️ 개별 Todo 상태 변경
                    ref.read(todoListProvider.notifier).toggle(todo.id);
                  },
                ),
                trailing: IconButton(
                  icon: Icon(Icons.delete),
                  onPressed: () {
                    // ✏️ Todo 삭제
                    ref.read(todoListProvider.notifier).remove(todo.id);
                  },
                ),
              );
            },
          ),
        ),
      ],
    );
  }
}

⭐️ 핵심: 상태 반영이 이루어져야 될 때만 ref.watch를 사용하자!

3.7 React useReducer와의 비교

React에서는 useReducer를 사용해봤다면, reducer 함수 내에서 상태 업데이트 로직을 구현하고, dispatch 함수에 action을 전달하여 실제 상태를 업데이트하는 거에 익숙할 것이다.


useReducer 사용패턴

// reducer 함수
function todoReducer(){
    switch (action){
        case "add":
            ...
        case "subtract"
            ...
    }
}

const [todos, dispatch] = useReducer(todoReducer, [])

// dispatch 함수를 통해 action 전달하여 상태 업데이트
dispatch({ type: "ADD_TODO", payload: { title: "New Todo" } })
dispatch({ type: "TOGGLE_TODO", payload: { id: "1" } })

Riverpod Notifier

final todos = ref.watch(todoListProvider);
final notifier = ref.read(todoListProvider.notifier);

// 메서드 직접 호출 (더 직관적!)
notifier.add('New Todo');
notifier.toggle('1');

reducer함수처럼, flutter에서 Nofitifer 내에 상태 업데이트 로직을 구현하고 실제 상태를 업데이트하는 로직을 전달할 때는 ref.watch or ref.read 로 Notifier를 구독한 뒤 Notifier의 값(현재 상태)이나 메서드를 불러올 수 있다.

4️⃣ 애플리케이션 로직 구현

4.1 Todo 추가 기능

메인 화면에서 새로운 Todo를 추가하는 로직:

// views/todo_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:todo/providers/todo_providers.dart';
import 'widgets/todo_list.dart';

class TodoPage extends ConsumerStatefulWidget {  // 👈 StatefulWidget + Consumer
  const TodoPage({super.key});

  
  ConsumerState<TodoPage> createState() => _TodoPageState();
}

class _TodoPageState extends ConsumerState<TodoPage> {
  final _controller = TextEditingController();

  void _submit() {
    final text = _controller.text.trim();
    if (text.isNotEmpty) {
      ref.read(todoListProvider.notifier).add(text);  // 👈 Todo 추가
      _controller.clear();  // 입력 필드 비우기
    }
  }

  
  void dispose() {
    _controller.dispose();  // 메모리 누수 방지
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('📋 Todo 리스트')),
      body: Column(
        children: [
          // 입력 부분
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    onSubmitted: (_) => _submit(),  // 엔터 키로도 추가 가능
                    decoration: const InputDecoration(
                      hintText: '할 일을 입력하세요',
                      border: OutlineInputBorder(),
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                FilledButton(
                  onPressed: _submit,
                  child: const Text('추가')
                ),
              ],
            ),
          ),
          // Todo 리스트
          const Expanded(child: TodoList()),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _submit,
        child: const Icon(Icons.add),
      ),
    );
  }
}

4.2 Todo 삭제 및 토글 기능

개별 Todo 아이템의 UI와 상호작용:

// views/widgets/todo_item.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:todo/models/todo.dart';
import 'package:todo/providers/todo_providers.dart';

class TodoItem extends ConsumerWidget {
  final Todo todo;

  const TodoItem({super.key, required this.todo});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return ListTile(
      // 체크박스
      leading: Checkbox(
        value: todo.isDone,
        onChanged: (_) => ref.read(todoListProvider.notifier).toggle(todo.id),
      ),
      // 할 일 내용
      title: Text(
        todo.title,
        style: TextStyle(
          decoration: todo.isDone
            ? TextDecoration.lineThrough  // 완료된 항목은 취소선
            : null,
        ),
      ),
      // 삭제 버튼
      trailing: IconButton(
        icon: const Icon(Icons.delete),
        onPressed: () => ref.read(todoListProvider.notifier).remove(todo.id),
      ),
    );
  }
}

5.⭐️ Best Practice 정리

5-1. Provider 네이밍 규칙

// ✅ 좋은 예 - Provider임을 명확히 표시
final userProvider = Provider<User>(...);
final todoListProvider = NotifierProvider<TodoNotifier, List<Todo>>(...);

// ❌ 나쁜 예 - 무엇인지 알기 어려움
final user = Provider<User>(...);
final todos = NotifierProvider<TodoNotifier, List<Todo>>(...);

5-2. 상태와 UI 분리

// ✅ 좋은 예 - 비즈니스 로직을 Notifier에 캡슐화
class TodoNotifier extends Notifier<List<Todo>> {
  void addTodo(String title) {
    if (title.trim().length < 3) {
      // 유효성 검사 로직도 여기에!
      return;
    }
    // 추가 로직...
  }
}

// ❌ 나쁜 예 - Widget에서 직접 상태 조작
ElevatedButton(
  onPressed: () {
    ref.read(provider.notifier).state = [...state, newTodo];  // 😱
  },
);

5-3. 적절한 Provider 타입 선택

// 간단한 값일 때
final counterProvider = StateProvider<int>((ref) => 0);

// 복잡한 객체나 비즈니스 로직이 있을 때
final todoListProvider = NotifierProvider<TodoNotifier, List<Todo>>(
  TodoNotifier.new
);

// 비동기 작업이 있을 때
final weatherProvider = FutureProvider<Weather>((ref) async {
  return await weatherApi.getCurrentWeather();
});

5-4. 에러 처리

// AsyncNotifier를 사용한 에러 처리
final todosProvider = AsyncNotifierProvider<TodosNotifier, List<Todo>>(
  TodosNotifier.new,
);

// UI에서 에러 상태 처리
final todosAsync = ref.watch(todosProvider);

return todosAsync.when(
  data: (todos) => TodoList(todos),
  loading: () => const CircularProgressIndicator(),
  error: (error, stack) => Text('오류가 발생했어요: $error'),
);

5-5. 테스트하기 쉬운 구조

// Provider를 가짜 구현으로 교체하여 테스트
testWidgets('Todo 추가 테스트', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        todoListProvider.overrideWith(() => MockTodoNotifier()),
      ],
      child: MyApp(),
    ),
  );

  // 테스트 로직...
});

마무리 🎉

Riverpod을 언제 사용해야 할까? 🤔

이번 Todo 앱을 만들면서 Riverpod을 사용해보니, 다음과 같은 상황에서 특히 유용하다고 느꼈다:

✅ Riverpod을 사용하면 좋은 경우:

  • 여러 화면에서 같은 데이터를 공유해야 할 때 (로그인 정보, 장바구니 등)
  • 복잡한 비즈니스 로직이 있을 때 (Todo 추가/수정/삭제 등)
  • 테스트가 중요한 프로젝트일 때 (Provider 교체로 쉬운 테스트)
  • 타입 안전성이 중요할 때 (컴파일 타임 오류 검출)

❌ 굳이 Riverpod까지 필요 없는 경우:

  • 단순한 UI 상태만 관리하는 경우 (setState로 충분)
  • 한 화면에서만 사용하는 데이터인 경우
  • 프로젝트 규모가 매우 작은 경우

큰 관점에서 배운 점 📚

1. 상태 관리의 본질

Riverpod을 통해 상태 관리의 핵심은 **"누가, 언제, 어떻게 데이터를 관리할 것인가"**라는 것을 깨달았다. React의 상태 관리와 비슷하지만, Flutter의 Widget 생명주기와 더 자연스럽게 통합된다는 점이 인상적이었다.

2. 선언적 UI의 힘

// 상태가 변하면 UI가 자동으로 업데이트됨
final todos = ref.watch(todoListProvider);  // 이것만으로 끝!

상태가 변하면 자동으로 UI가 업데이트되는 선언적 패러다임이 얼마나 강력한지 체감했다. 개발자는 "무엇을 보여줄 것인가"에만 집중하면 된다.

3. 불변성의 중요성

// ❌ 기존 객체 수정
state[0].isDone = true;

// ✅ 새 객체 생성
state = state.map((todo) =>
  todo.id == id ? todo.copyWith(isDone: true) : todo
).toList();

React와 마찬가지로 불변성을 유지해야 상태 변화를 정확히 감지할 수 있다는 것을 다시 한번 확인했다.

4. 관심사의 분리

  • Model: 데이터 구조 정의 (Todo 클래스)
  • Notifier: 비즈니스 로직 (TodoNotifier)
  • Provider: 상태 제공 (todoListProvider)
  • Consumer: UI 렌더링 (TodoList, TodoItem)

React 개발자 관점에서의 Riverpod 💭

React에서 Redux나 Zustand를 사용해본 경험이 있다면, Riverpod의 접근 방식이 매우 친숙하게 느껴질 것이다:

React 생태계 Flutter Riverpod 공통점
useSelector ref.watch() 상태 구독
dispatch ref.read().method() 상태 변경
Provider ProviderScope 상태 제공자
useEffect ref.listen() 부수 효과

결론

Riverpod은 처음에는 복잡해 보일 수 있지만, 본질적으로 React의 상태 관리와 크게 다르지 않다.

© 2025, 정인영, Built with Gatsby